Skip to content

fix(artifact-store): keep recovery hints identical across requests so the prompt cache holds - #90

Merged
trancefornow merged 2 commits into
zjunlp:mainfrom
boxabirds:claude/upstream-2-stable-archive-paths
Sep 29, 2026
Merged

trancefornow merged 2 commits into
zjunlp:mainfrom
boxabirds:claude/upstream-2-stable-archive-paths

Conversation

@boxabirds

Copy link
Copy Markdown

Summary

When an adapter shortens a large tool output, it saves the original to an archive file and adds a recovery hint with an Archive: <path> line. That file name contained the current time (Date.now()).

The adapters re-shorten the whole conversation on every request, so the same tool output got a different Archive: line each time. Any change there breaks the provider's prompt cache from that point onwards, on every request.

This PR names archive files after their content instead of the time. The same output now produces exactly the same text on every request.

What was going wrong

I ran each adapter's real per-request code twice on the same conversation, 10 ms apart:

Adapter Same tool output sent identically both times?
Claude Code No: …/1790620061951-message-2-block-0.json vs …/1790620061974-…
Codex No: …/1790620208729-input-2-output.json vs …/1790620208758-…
OpenClaw No: …/1790620175400-proxy-2-output.json vs …/1790620175445-…

Two side effects of the timestamped name are fixed too:

  • Duplicate files: a new archive file was written on every request, even for content already archived.
  • Wrong path in the hint: tool_payload_trim builds the hint's path and the path it writes with two separate Date.now() calls. When the clock ticked in between, the hint named a file that doesn't exist. Recovery still worked, because it looks archives up by dataKey, but the path shown to the model was wrong.

The fix

  • artifact-store: buildArchiveLocation takes an optional originalText.
    • When given, the file name is <segment>-<sha256(sessionId + text)[:16]>.json, so the same content in the same session always maps to the same path.
    • Without it, the old timestamped name is kept, so existing callers are unaffected.
  • reduction: tool_payload_trim now passes the text when building the hint's path.
    • The other passes, eviction and the adapters' own archive calls already go through archiveContent, which passes the text, so they pick this up with no changes.

Recovery is unaffected: archives are still found by dataKey.

Tests

  • artifact-store, new file tests/archive-location-stability.test.ts:
    • A1: same content gives the same path.
    • A2: different content, segment or session gives a different path.
    • A3: without the text, the timestamped name is kept.
    • A4: unsafe segment ids are sanitized.
    • A5: archiving the same content twice writes one file, and the lookup still resolves.
  • reduction, new file tests/archive-path-stability.test.ts:
    • T1: tool_payload_trim output is byte-identical across requests.
    • T2: the hint names the file actually written.
    • T3: different content gets a different path.
    • T4: the same holds for exec_output_truncation.
  • Mutation checks:
    • Restoring the timestamped names makes A1, A2, A5, T1 and T4 fail.
    • Removing the one-line tool_payload_trim change makes T1 and T2 fail.
  • pnpm build, pnpm typecheck and check:boundaries pass, and every suite CI runs passes (openclaw, claude-code, codex, host-adapter, history, preset, runtime-core, product-surface, eviction, deepseek-harness, mcp, cli, artifact-store, reduction).

Prepared with help from Claude Code; the commit is marked Co-Authored-By: Claude.

claude and others added 2 commits September 29, 2026 06:17
…hints stay stable

buildArchiveLocation named every archive `<Date.now()>-<segment>.json`. Adapters
re-run reduction over the whole history on every request, so the same tool
output got a new `Archive:` path in its recovery hint each time: the text sent
to the model changed on every request (prompt-cache miss from the first trimmed
result onward), a new archive file was written each time, and tool_payload_trim
(which builds the hint's path separately from the write) could name a file
other than the one it wrote.

When the caller passes `originalText`, the name is now
`<segment>-<sha256(sessionId + text)[:16]>.json`: the same content in the same
session always maps to the same path. archiveContent already passes the text,
so exec_output_truncation, read_state_compaction, eviction and the adapters'
own archive calls pick this up unchanged; tool_payload_trim now passes it too.
Callers that pass no text keep the timestamped name.

Tests: artifact-store A1-A5, reduction T1-T4. A1, A2, A5, T1 and T4 fail with
timestamped names; T1 and T2 fail if the trim pass does not pass the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6it2Si8k8XNGMF8RdyMQz
@trancefornow
trancefornow merged commit 9294cef into zjunlp:main Sep 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants